contents

"상속보다 컴포지션"은 객체 지향 프로그래밍의 근본적인 디자인 원칙으로, 클래스 상속("is-a" 관계)보다 컴포지션("has-a" 관계)을 선호해야 한다는 것을 말합니다.

이 원칙은, 복잡한 기능을 구축할 때 부모 클래스로부터 _상속_받는 것보다, 필요한 기능을 제공하는 다른 클래스의 인스턴스를 _포함_하는 것(컴포지션)이 종종 더 유연하고, 유지보수하기 좋으며, 견고한 방법이라고 제안합니다.

각 개념, 상속의 문제점, 그리고 왜 컴포지션이 더 나은 선택인지 자세히 살펴보겠습니다.


1. 상속이란 무엇인가? ("IS-A" 관계)

상속은 기존 클래스(상위 클래스 또는 부모 클래스)를 기반으로 새로운 클래스(하위 클래스 또는 자식 클래스)를 형성하는 방법입니다. 자식 클래스는 부모의 모든 필드와 메서드를 물려받아 코드를 재사용하고 명확한 "is-a" 계층을 만들 수 있게 합니다.

상속의 문제점

상속은 간단한 경우에는 유용하지만, 시스템이 커짐에 따라 다음과 같은 몇 가지 주요 단점이 나타납니다.


2. 컴포지션이란 무엇인가? ("HAS-A" 관계)

컴포지션은 필요한 기능을 제공하는 다른 클래스의 인스턴스를 _포함_함으로써 클래스를 구축하는 방식입니다. 동작을 상속받는 대신, 클래스가 자신의 구성 요소에게 작업을 _위임_합니다.

컴포지션이 더 나은 이유

컴포지션은 상속의 문제점들을 직접적으로 해결합니다.


상세 예제: "로봇" 문제

다양한 유형의 로봇이 있는 시스템을 설계해 봅시다. 로봇은 움직이고 작업을 수행해야 합니다.

상속 기반 접근 (나쁜 예)

먼저 Robot이라는 기반 클래스를 만들고 다른 로봇들이 이를 상속받도록 할 수 있습니다.

// 기반 클래스
abstract class Robot {
    public void move() {
        System.out.println("걷는 중...");
    }
    public abstract void performTask();
}

// 자식 클래스들
class CleanerRobot extends Robot {
    @Override
    public void performTask() {
        System.out.println("바닥 청소 중.");
    }
}

class KillerRobot extends Robot {
    @Override
    public void performTask() {
        System.out.println("타겟 제거 중.");
    }
}

괜찮아 보이지만, 새로운 요구사항이 생겼습니다.

  1. FlyingRobot이 필요합니다. fly() 메서드를 어떻게 추가할까요? 만약 Robot 기반 클래스에 추가하면, CleanerRobotfly()를 상속받게 되는데 이는 말이 되지 않습니다.
  2. FlyingKillerRobot이 필요합니다. 이제 큰 문제가 생겼습니다. FlyingRobotKillerRobot 둘 다를 상속받을 수 없습니다. 이 상속 계층은 경직되어 있으며 이미 한계에 부딪혔습니다.

컴포지션 기반 접근 (좋은 예)

로봇이 무엇인지 가 아니라, 무엇을 하는지 에 대해 생각해 봅니다. 로봇의 행동들을 별도의 교체 가능한 "전략" 객체로 정의합니다.

1단계: 행동 인터페이스 정의

interface MoveBehavior {
    void move();
}
interface TaskBehavior {
    void perform();
}

2단계: 구체적인 행동 클래스 생성

// 움직임 행동
class WalkBehavior implements MoveBehavior {
    public void move() { System.out.println("걷는 중..."); }
}
class FlyBehavior implements MoveBehavior {
    public void move() { System.out.println("하늘을 나는 중!"); }
}

// 작업 행동
class CleanBehavior implements TaskBehavior {
    public void perform() { System.out.println("바닥 청소 중."); }
}
class KillBehavior implements TaskBehavior {
    public void perform() { System.out.println("타겟 제거 중."); }
}

3단계: Robot 클래스를 컴포지션으로 구성

Robot 클래스는 움직임 행동을 가지고 있고, 작업 행동을 가지고 있습니다.

class Robot {
    private MoveBehavior moveBehavior;
    private TaskBehavior taskBehavior;

    // 생성 시 행동들을 "주입"받음
    public Robot(MoveBehavior m, TaskBehavior t) {
        this.moveBehavior = m;
        this.taskBehavior = t;
    }

    // Robot이 자신의 구성 요소에게 작업을 위임
    public void doMove() {
        this.moveBehavior.move();
    }
    public void doTask() {
        this.taskBehavior.perform();
    }
    
    // 런타임에 행동을 변경할 수도 있습니다!
    public void setMoveBehavior(MoveBehavior m) {
        this.moveBehavior = m;
    }
}

이제, 새로운 로봇을 만드는 것이 매우 유연해졌습니다.

// 걷는 청소 로봇
Robot cleaner = new Robot(new WalkBehavior(), new CleanBehavior());
cleaner.doMove(); // "걷는 중..."
cleaner.doTask(); // "바닥 청소 중."

// 복잡했던 "나는 킬러 로봇"이 이제 쉬워졌습니다!
Robot killer = new Robot(new FlyBehavior(), new KillBehavior());
killer.doMove(); // "하늘을 나는 중!"
killer.doTask(); // "타겟 제거 중."

// 즉석에서 행동을 변경할 수도 있습니다
killer.setMoveBehavior(new WalkBehavior());
killer.doMove(); // "걷는 중..."

상속은 언제 사용해도 괜찮은가?

이 원칙은 "절대 상속을 쓰지 말라"가 아니라, "컴포지션을 선호하라"는 것입니다. 상속은 다음과 같은 몇 가지 특정 경우에 여전히 유효하고 유용한 도구입니다.

  1. 진정한 "IS-A" 관계일 때: 하위 클래스가 단지 행동의 집합이 아니라 상위 클래스의 진정한 "is-a" 전문화일 때 (예: DogCat은 둘 다 Animal이다).
  2. 다형성을 위할 때: 서로 다른 객체들을 동일한 방식으로 다루고 싶을 때 (예: DogCat 객체를 모두 담는 List<Animal>).
  3. 프레임워크 확장을 위해: 종종 프레임워크를 사용할 때 기반 클래스를 상속하도록 요구받습니다 (예: 리액트의 class MyComponent extends React.Component).

좋은 경험 법칙:

references